ECE 5725 Final Project: RPi Autonomous Guitar Tuner

Cynthia Zelga (cnz5) & Sizhe Zhang (sz592)
December 13, 2019
« Introduction Design & Testing Results Conclusions Future Work Budget References Code Appendix

Code Appendix


          ################################################################################
          # Sizhe Zhang (sz592), Cynthia Zelga (cnz5)
          # ECE 5725 Final Project: RPi Autonomous Guitar Tuner
          # Monday lab section
          # Dec 13, 2019

          ############################ SET-UP CODE START #################################
          # Import necessary modules
          import sys, pygame
          import os # Used to display onto the piTFT
          import time # Used for keeping track of time elapsed for while loop conditions
          import pyaudio # Used to process audio input recorded by means of microphone
          import numpy as np # Used for mathematical functions as well as the FFT algorithm
          from pygame.locals import * # For event MOUSE variables
          import servocontrol as sc # Module that includes set up of servo and defines servo control helper functions
          import RPi.GPIO as GPIO # library for utilizing I/O pins, which was used to connect the servo motor to the RPi

          # For displaying on piTFT or display
          os.putenv('SDL_VIDEODRIVER','fbcon') # Display on piTFT
          os.putenv('SDL_FBDEV','/dev/fb1')
          os.putenv('SDL_MOUSEDRV', 'TSLIB') # Track mouse clicks on piTFT
          os.putenv('SDL_MOUSEDEV', '/dev/input/touchscreen')

          # Frequency Detection algorithm - adapted from one found online - link in References section!!
          form_1 = pyaudio.paInt16 # 16-bit resolution
          chans = 1 # 1 channel
          samp_rate = 44100 # 44.1kHz sampling rate
          chunk = 44100 # Samples for buffer (more samples = better freq resolution)
          dev_index = 2 # Device index found by p.get_device_info_by_index(ii)
          audio = pyaudio.PyAudio() # Create pyaudio instantiation
          # Mic sensitivity correction and bit conversion
          mic_sens_dBV = -47.0 # Mic sensitivity in dBV + any gain
          mic_sens_corr = np.power(10.0,mic_sens_dBV/20.0) # Calculate mic sensitivity conversion factor
          # Create pyaudio stream
          stream = audio.open(format = form_1,rate = samp_rate,channels = chans, \
                              input_device_index = dev_index,input = True, \
                              frames_per_buffer=chunk)

          # Initialize PyGame
          pygame.init()
          pygame.mouse.set_visible(False) # Set to True when debugging on monitor display, but False when running on piTFT
          width = 320
          height = 240
          screen = pygame.display.set_mode((width,height)) # Setting piTFT screen dimensions

          # Initialize colors used for graphics
          WHITE = 255,255,255
          BLACK = 0,0,0
          PINK = 255,153,238
          RED = 255,0,43
          GREEN = 77,255,166
          BLUE = 25,217,255
          YELLOW = 229,255,102
          PURPLE = 179,102,255

          # Buttons that display on "choice_level" screen
          # This is where the user selects which string they would like to tune. The screen
          # features 6 different-colored buttons, labeled with the scientific pitch notation for
          # each guitar string.
          tuning_x = 80 # x-position of top left button
          tuning_y = 80 # y-position of to left button
          radius = 20 # Radius of each button
          tuning_x_gap = 80 # Spacing in x-direction between buttons
          tuning_y_gap = 80 # Spacing in y-direction between buttons

          # Text variables
          my_font = pygame.font.Font(None,30) # Font size
          my_font_small = pygame.font.Font(None,20) # Font size - small
          # Text for message that displays on the 1st screen
          # Frequency-detection algorithm first listens and records background noise for 5 seconds
          text_surface_noise1 = my_font_small.render('Please wait 5 seconds',True,WHITE) # First line of message
          rect_noise1 =  text_surface_noise1.get_rect(center=(160,120))
          text_surface_noise2 = my_font_small.render('to record background noise... ',True,WHITE) # Second line of message
          rect_noise2 =  text_surface_noise2.get_rect(center=(160,140))
          # Labels for buttons on "choice-level" screen; text displays scientific pitch notation for each guitar string
          text_surface_E = my_font.render('E4',True,BLACK)
          text_surface_B = my_font.render('B3',True,BLACK)
          text_surface_G = my_font.render('G3',True,BLACK)
          text_surface_D = my_font.render('D3',True,BLACK)
          text_surface_A = my_font.render('A2',True,BLACK)
          text_surface_E2 = my_font.render('E2',True,BLACK)

          rect_E =  text_surface_E.get_rect(center=(80,80)) # Top-left button
          rect_B =  text_surface_B.get_rect(center=(160,80))
          rect_G =  text_surface_G.get_rect(center=(240,80))
          rect_D =  text_surface_D.get_rect(center=(80,160))
          rect_A =  text_surface_A.get_rect(center=(160,160))
          rect_E2 =  text_surface_E2.get_rect(center=(240,160))


          GPIO.setmode(GPIO.BCM) # Use Broadcom GPIO numbers system
          GPIO.setup(17, GPIO.IN, pull_up_down=GPIO.PUD_UP) # Set-up GPIO17 as an input, enable pullup resistor

          #Define callback routine for quitting using physical quit button (GPIO17)
          def GPIO17_callback(channel):
              global program_quit # Variable used for quitting program
              program_quit = True
              global tuning # Variable used for while-loop used for "tuning" screen
              tuning = False
              global choice_level # Variable used for while-loop displaying "choice_level" screen
              choice_level = False

          # Define event for GPIO17 physical quit button, linking callback routine execution to a push of the button
          GPIO.add_event_detect(17, GPIO.FALLING, callback=GPIO17_callback, bouncetime=300)
          ############################## SET-UP CODE END #################################

          ############################## MAIN CODE START #################################
          # Initializing variable used for specificying direction of servo motor rotation
          direction = 0 # 0 - stop, 1 - clockwise, 2 - counterclockwise, 3 - stop because tuning finished!
          program_quit = False # Variable used for quitting program if True
          # Used to set color of circle displayed on "tuning" screen to the color of the circle selected
          # on "choice_level" screen
          curr_color = BLACK

          # While the program is running
          while not program_quit:
              for event in pygame.event.get():
                  if event.type == pygame.QUIT: sys.exit()
              start_time = time.time() # Record start time
              noise_fft_vec,noise_amp_vec = [],[]
              ii = 0
              noise_len = 5
              # Loop for recording noise; displays message on screen telling user to wait 5 seconds for noise to be recorded
              # Frequency detection algorithm adapted from online source - link in References section!!
              while time.time()-start_time < 5 and not program_quit:
                  screen.fill(BLACK)
                  screen.blit(text_surface_noise1,rect_noise1)
                  screen.blit(text_surface_noise2,rect_noise2)
                  pygame.display.flip() # Display message on screen

                  # Record Noise
                  stream.start_stream()
                  data = np.fromstring(stream.read(chunk),dtype=np.int16)
                  if ii==noise_len:
                      data = data-noise_amp
                  data = ((data/np.power(2.0,15))*5.25)*(mic_sens_corr)
                  stream.stop_stream()
                  # Compute FFT
                  fft_data = (np.abs(np.fft.fft(data))[0:int(np.floor(4410/2))])/chunk
                  fft_data[1:] = 2*fft_data[1:]
                  # Calculate and subtract average spectral noise
                  if ii < noise_len:
                      if ii==0:
                          noise_fft_vec.append(fft_data)
                          noise_amp_vec.extend(data)
                      if ii==noise_len-1:
                          noise_fft = np.max(noise_fft_vec,axis=0)
                          noise_amp = np.mean(noise_amp_vec)
                      ii+=1
                      continue
                  fft_data = np.subtract(fft_data,noise_fft) # Subtract average spectral noise

              # After recording noise for 5 seconds, display "choice_level" screen
              # Displays the 6 different-colored buttons labeled with the sceintic pitch notation of each string
              while not program_quit:
                  choice_level = True # Display "choice_level" screen
                  #Loop for choice of tuning
                  while choice_level:
                      # Draw screen contents
                      screen.fill(BLACK)
                      pygame.draw.circle(screen,RED,[80,80],30)
                      pygame.draw.circle(screen,YELLOW,[160,80],30)
                      pygame.draw.circle(screen,BLUE,[240,80],30)
                      pygame.draw.circle(screen,GREEN,[80,160],30)
                      pygame.draw.circle(screen,PINK,[160,160],30)
                      pygame.draw.circle(screen,PURPLE,[240,160],30)
                      screen.blit(text_surface_E,rect_E)
                      screen.blit(text_surface_B,rect_B)
                      screen.blit(text_surface_G,rect_G)
                      screen.blit(text_surface_D,rect_D)
                      screen.blit(text_surface_A,rect_A)
                      screen.blit(text_surface_E2,rect_E2)
                      dest_freq = 0 # Will be set to the value of the target frequency of the selected string
                      text_surface_quit = my_font_small.render("Quit",True,WHITE) # Quit button displayed on screen
                      rect_quit =  text_surface_quit.get_rect(center=(300,20))
                      screen.blit(text_surface_quit,rect_quit)

                      # Touch detection
                      for event in pygame.event.get():
                          if event.type is MOUSEBUTTONDOWN:
                              pos = pygame.mouse.get_pos() # Touch detection
                              x,y = pos
                              # E4
                              if y > tuning_y-radius and y < tuning_y+radius:
                                  if x > tuning_x-radius and x < tuning_x+radius:
                                      dest_freq = 329.6
                                      choice_level = False
                                      curr_color = RED
                              # D3
                              if y > tuning_y-radius+tuning_y_gap and y < tuning_y+radius+tuning_y_gap:
                                  if x > tuning_x-radius and x < tuning_x+radius:
                                      dest_freq = 146.83
                                      choice_level = False
                                      curr_color = GREEN
                              # B3
                              if y > tuning_y-radius and y < tuning_y+radius:
                                  if x > tuning_x-radius+tuning_x_gap and x < tuning_x+radius+tuning_x_gap:
                                      dest_freq = 246.94
                                      choice_level = False
                                      curr_color = YELLOW
                              # A2
                              if y > tuning_y-radius+tuning_y_gap and y < tuning_y+radius+tuning_y_gap:
                                  if x > tuning_x-radius+tuning_x_gap and x < tuning_x+radius+tuning_x_gap:
                                      dest_freq = 110.00
                                      choice_level = False
                                      curr_color = PINK
                              # G3
                              if y > tuning_y-radius and y < tuning_y+radius:
                                  if x > tuning_x-radius+tuning_x_gap*2 and x < tuning_x+radius+tuning_x_gap*2:
                                      dest_freq = 196.00
                                      choice_level = False
                                      curr_color = BLUE
                              # E2
                              if y > tuning_y-radius+tuning_y_gap and y < tuning_y+radius+tuning_y_gap:
                                  if x > tuning_x-radius+tuning_x_gap*2 and x < tuning_x+radius+tuning_x_gap*2:
                                      dest_freq = 82.41
                                      choice_level = False
                                      curr_color = PURPLE
                              # Detect touch of quit button displayed on screen
                              if y > 0 and y < 30:
                                  if x > 290 and x < 320:
                                      program_quit = True
                                      choice_level = False
                                      tunning = False
                                      choice_level =False
                      tuning = True # After a string has been selected, the "tuning" screen will be displayed next
                      new_sound = False # New detected sound initialized to False
                      pygame.display.flip()
                      curr_freq_d = dest_freq # Set curr_freq_d to dest_freq initially
                      reminder = "Please strum..." # This message will be displayed on the "tuning" screen
                      tuning_time = 0 # Initialized to 0; will be used to control how long servo motor will rotate for
                      direction = 0 # Initialized servo to stopped

                  # Loop for displaying "tuning" screen until the selected string has been successfully tuned
                  while tuning:
                      # Setting colors for circle that will be displayed to represent current frequency;
                      # color will be slightly lighter than center circle
                      # If current frequency is lower than target, circle will be drawn offset to the left
                      # If current frequency is higher than target, circle will be drawn offset to the right
                      rval = curr_color[0]*0.75
                      gval = curr_color[1]*0.75
                      bval = curr_color[2]*0.75
                      cval = rval, gval, bval
                      screen.fill(BLACK)
                      curr_x = int(( curr_freq_d - dest_freq )) + 160 # Sets x-pos of current frequency circle

                      # Draw current frequency circle
                      text_surface_curr_freq_d = my_font.render("Current frequency: "+str(curr_freq_d)+" Hz",True,WHITE)
                      rect_curr_freq_d =  text_surface_curr_freq_d.get_rect(center=(160,80))
                      pygame.draw.circle(screen,cval,[curr_x,120],30)
                      screen.blit(text_surface_curr_freq_d,rect_curr_freq_d)

                      # Draw target frequency circle
                      text_surface_dest_freq = my_font.render(str(dest_freq),True,BLACK)
                      rect_dest_freq =  text_surface_dest_freq.get_rect(center=(160,120))
                      pygame.draw.circle(screen,curr_color,[160,120],30)
                      screen.blit(text_surface_dest_freq,rect_dest_freq)

                      # Draw reminder message "to strum" onto screen
                      text_surface_reminder = my_font_small.render(reminder,True,WHITE)
                      rect_reminder =  text_surface_reminder.get_rect(center=(160,180))
                      screen.blit(text_surface_reminder,rect_reminder)

                      # Starts listening for guitar strum (listens roughly every second)
                      stream.start_stream()
                      data = np.fromstring(stream.read(chunk),dtype=np.int16)
                      data = data-noise_amp # Subtracts out noise
                      data = ((data/np.power(2.0,15))*5.25)*(mic_sens_corr)
                      stream.stop_stream() # Stops listening
                      fft_data = (np.abs(np.fft.fft(data))[0:int(np.floor(chunk/2))])/chunk # Compute FFT
                      fft_data[1:] = 2*fft_data[1:]

                      f_vec = samp_rate*np.arange(chunk/2)/chunk
                      low_freq_loc = np.argmin(np.abs(f_vec))
                      curr_freq = np.argmax(fft_data[low_freq_loc:]) # Detected current frequency

                      # If the amplitude of the detected frequency is larger than noise amplitude-level limit and
                      # the difference in frequency is at least 2 Hz off and the current frequency is too low
                      if fft_data[curr_freq]>0.00001 and curr_freq-dest_freq < 2:
                          curr_freq_d = curr_freq # Current frequency detected
                          new_sound = True # New sound has been detected

                          # If current frequency is too low
                          if dest_freq-curr_freq > 2:
                              direction = 1 # Turn servo motor clockwise
                              if dest_freq-curr_freq>20:
                                  tuning_time = 0.6 # Turn servo 1/2 a full rotation
                              else:
                                  # Turn servo using ratio; 1 full rotation of servo was found to correspond to 20 Hz
                                  tuning_time = 0.5*((dest_freq-curr_freq)/20)
                          elif curr_freq-dest_freq < 2: # Once the difference between frequencies is less than 2 Hz
                              tuning = False # Tuning is complete!
                              tuning_time = 0
                              direction = 3 # Set servo motor to stopped because tuned!
                      screen.fill(BLACK)
                      curr_x = int(( curr_freq_d - dest_freq )) + 160 # Update position of current frequency circle

                      # Draw current frequency circle
                      text_surface_curr_freq_d = my_font.render("Current frequency: "+str(curr_freq_d)+" Hz",True,WHITE)
                      rect_curr_freq_d =  text_surface_curr_freq_d.get_rect(center=(160,80))
                      pygame.draw.circle(screen,cval,[curr_x,120],30)
                      screen.blit(text_surface_curr_freq_d,rect_curr_freq_d)

                      # Draw target frequency circle
                      text_surface_dest_freq = my_font.render(str(dest_freq),True,BLACK)
                      rect_dest_freq =  text_surface_dest_freq.get_rect(center=(160,120))
                      pygame.draw.circle(screen,curr_color,[160,120],30)
                      screen.blit(text_surface_dest_freq,rect_dest_freq)

                      # Draw reminder "to strum" onto screen
                      text_surface_reminder = my_font_small.render(reminder,True,WHITE)
                      rect_reminder =  text_surface_reminder.get_rect(center=(160,180))
                      screen.blit(text_surface_reminder,rect_reminder)

                      if new_sound:
                          start_time = time.time() # Record start time if new sound detected

                      if direction == 1 and time.time()-start_time < tuning_time: # Turn clockwise
                          reminder = "Please wait"
                          # Call helper function to turn servo clockwise for tuning_time amount of seconds
                          sc.halfSpeed("cw", tuning_time)
                          # Added a small delay as guitar string strums have reverb;
                          # this ensures same string is not detected again
                          time.sleep(0.75)
                      elif direction == 3: # Tuning is done!
                          reminder = "Finished tuning!"
                          # Redraw screen with finished state
                          time_back = time.time()
                          while time.time()-time_back < 4:
                              screen.fill(BLACK)
                              curr_x = int(( curr_freq_d - dest_freq )) + 160

                              # Draw message notifying user that string is tuned!! This displays for 4 seconds
                              text_surface_reminder = my_font.render(reminder,True,WHITE)
                              rect_reminder =  text_surface_reminder.get_rect(center=(160,180))
                              screen.blit(text_surface_reminder,rect_reminder)
                              pygame.display.flip()
                      else: # Otherwise, no sound was detected, so tell the user to strum again
                          direction = 0 # Servo motor should be stopped
                          reminder = "Please strum..."

                      new_sound = False # Reset

                      # Touch detection for go back or quit program
                      text_surface_back = my_font_small.render("Back",True,WHITE)
                      rect_back =  text_surface_back.get_rect(center=(300,220))
                      screen.blit(text_surface_back,rect_back)

                      text_surface_quit = my_font_small.render("Quit",True,WHITE)
                      rect_quit =  text_surface_quit.get_rect(center=(300,20))
                      screen.blit(text_surface_quit,rect_quit)

                      for event in pygame.event.get():
                          if event.type is MOUSEBUTTONDOWN:
                              pos = pygame.mouse.get_pos() # Touch detection
                              x,y = pos
                              # Detect touch of "back" button
                              if y > 200 and y < 240:
                                  if x > 280 and x < 320:
                                      tuning = False
                              # Detect touch of "quit" button
                              if y > 0 and y < 30:
                                  if x > 290 and x < 320:
                                      program_quit = True
                                      tuning = False

                      pygame.display.flip()